pybind11 回调线程析构 self-join 崩溃排查
一个嵌入了 pybind11 C++ 扩展的 Python 进程运行中崩溃(SIGABRT),core dump 显示崩溃点是 std::thread::join 抛出的 system_error,且异常没有任何人接住。最终定位:一个由 Python 引用计数托管生命周期的 C++ 对象,在自己的回调线程上被 decref 到零,析构函数里的 join() join 了自己(EDEADLK)。本文记录完整的取证过程与三层修复,适合写 pybind11 绑定、或需要在回调线程上析构复杂对象的同学参考。
背景
进程结构:Python 主程序 + pybind11 扩展模块,扩展封装了一套基于 Boost.Asio 与 ZeroMQ 的通信客户端(下文以 MsgCenter、ActionClient 代称内部类)。崩溃后留下 core dump,绑定模块与通信库都带调试符号,gdb python3 core 后 thread apply all bt 拿到全部线程栈。
崩溃现场
不是段错误。崩溃线程栈顶:
#0 raise
#1 abort
#2 __gnu_cxx::__verbose_terminate_handler
#3 __cxxabiv1::__terminate
#4 __cxa_call_terminate
#8 __cxxabiv1::__cxa_throw
#9 std::__throw_system_error (__i=35)
#10 std::thread::join (this=0xaaaaac017310) thread.cc:137
#11 MsgCenter::Join (this=0xaaaaac017238) 三个关键信息:
- 信号是 SIGABRT,来源是 C++ 异常未被捕获,
std::terminate调abort; - 异常由
std::thread::join抛出,__i=35是 errno:35 = EDEADLK(resource_deadlock_would_occur),即pthread_join检测到线程 join 自己; Join帧的this与该线程正在执行的事件循环帧的this完全一致——join 的目标就是当前线程,self-join 定案。
析构链
崩溃线程是 MsgCenter 的事件循环线程(io_context::run → 消息分发),一条请求-响应模式下的返回消息触发了整条链:
- zmq poll 收到消息,进入 ActionClient 的回调分发;
- 从
unordered_map里 erase 一个std::function回调; - erase 触发回调对象析构,其成员
shared_ptr释放,对持有的 Python 对象 decref; - 减掉了 C++ 包装对象的最后一个引用,进入 pybind11 的 deallocate 路径 → 包装类析构;
- 析构里走 Release → Join →
std::thread::join; - join 自己 → EDEADLK → 异常穿过线程入口(无 handler)→ terminate → abort。
根因
ActionClient 的生命周期托管给 Python 引用计数,而最后一个引用在它自己的回调线程上被释放:消息到达 → 回调 erase → 引用计数减到零 → 整条 C++ 对象图在回调线程上同步销毁,析构函数里 join 了自己。属于「在回调路径上同步销毁回调所属对象」这一类设计缺陷。
放大因素:Join 直接 join(),既没有 self 判断也没有 catch——析构发生在哪个线程,哪个线程就把进程带崩。
修复
分三层:
Join防御,先止血:
void Join() {
if (worker_thread_.joinable() &&
worker_thread_.get_id() != std::this_thread::get_id()) {
worker_thread_.join();
}
} 把析构从回调线程移走,治本:回调 erase 的析构路径上,不要同步 decref 可能拥有对象的 Python 引用。先 incref,把最终释放 post 到安全线程(专门的清理线程或主线程)执行。
析构流程改异步收尾:析构先停止投递,再由独立的 reaper 线程 join,调用者线程不等待。
取证要点
- C++ 的 uncaught exception 在任何线程上都会 terminate。
std::thread::join抛出的system_error常见两种:invalid_argument(线程不可 join)和resource_deadlock_would_occur(self-join,errno 35); - 核心取证手法是比对
this指针:崩溃帧里 join 的对象和当前线程正在服务的对象是同一个,self-join 即可定案; - pybind11 把 C++ 对象生命周期挂到 Python 引用计数上时,decref 可能发生在任何持有 GIL 的线程(包括 C++ 回调线程),析构链必须按「可能在任意线程上执行」来设计;
- gdb 符号误归属:pybind11 的 deallocate 函数名被解析到了进程里另一个无关
.so的同名符号上(这里是 matplotlib 的内部模块),与该库无关,不要被带偏; - 线程池空闲等待(如 OpenBLAS 的
blas_thread_server)和阻塞在take_gil的线程都是 bystander,不是崩溃原因。